Skip to content

Mutate Event/Hook - #25275

Closed
Diddykonga wants to merge 4 commits into
bevyengine:mainfrom
Diddykonga:query_mutation_events
Closed

Diddykonga wants to merge 4 commits into
bevyengine:mainfrom
Diddykonga:query_mutation_events

Conversation

@Diddykonga

@Diddykonga Diddykonga commented Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Mutate Event/Hook

Objective

Lifecycle Hooks/Events currently cover Immutable Components changing value and changing structurally for all Components, but often times we would like it if it also covered Mutable Components changing value.

So that Lifecycle Hooks/Events cover All Components changing value or structurally.

Solution

Prior PR/Approach: #16143
Arrived at the same solution, didnt actually look at the code.

This approach attempts to give SystemParam::apply/queue the responsibility of triggering/queuing Mutate Events via Change Tick scans of their mutably-accessed Components.

Some things to note:

  1. Mutate Events are grouped by individual SystemParam, which in most cases is fine and leads to normal results, although some wierdness can occur when using a collection of SystemParams that happen to overlap. ex. ParamSet with two identical queries.
  2. The main driver for knowledge of access is, Access, which can contain Unbounded acessess's (ex. EntityMutExcept<...>). In these cases we are not given any definite knowledge as to what was accessed and what wasn't so we must scan every component, except the ones excluded if any.

Impled for Query, ResMut, FilteredResourcesMut.

Testing

  • Did you test these changes? If so, how? Barely, just ran bevy_city with and without. I'm not a good tester.
  • Are there any parts that need more testing? All of it, please.

Showcase

fn mutate_observer(mutate: On<Mutate>) {
    let entity = mutate.entity;
    let components = &mutate.components;
    println!("{mutate:?}");
}

fn mutate_component_observer(mutate: On<Mutate, Transform>) {
    let entity = mutate.entity;
    let components = &mutate.components;
    println!("{mutate:?}");
}

#[derive(Component)]
struct MyComponent {
    // ...
}

impl MyComponent {
    fn on_mutate(mut world: DeferredWorld, hook: HookContext) {
        // ...
    }
}

@JaySpruce JaySpruce added C-Feature A new feature, making something new possible A-ECS Entities, components, systems, and events S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Aug 6, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in ECS Aug 6, 2026
Mutate Event/Hook
@Diddykonga
Diddykonga force-pushed the query_mutation_events branch from b88a498 to 95260a9 Compare August 15, 2026 23:37
@Diddykonga
Diddykonga marked this pull request as ready for review August 16, 2026 03:55
Comment on lines +534 to +550
#[derive(Default, Debug)]
pub struct EntityMutateTrigger;

// SAFETY:
// - `E`'s [`Event::Trigger`] is constrained to [`EntityComponentsTrigger`]
unsafe impl<E: EntityEvent + for<'a> Event<Trigger<'a> = EntityMutateTrigger> + ContainsComponents>
Trigger<E> for EntityMutateTrigger
{
unsafe fn trigger(
&mut self,
world: DeferredWorld,
observers: &CachedObservers,
trigger_context: &TriggerContext,
event: &mut E,
) {
let entity = event.event_target();
let components: Vec<ComponentId> = event.components().into();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why you didn't use EntityComponentsTrigger for MutateEvent like the other lifecycle observers do?

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 50/50 on this, but mainly its because a Mutation shouldn't ever be an structural change, so no archetypes will ever change, so passing them is assumed to be useless information. (There is also overhead for Mutate to pass them in the queue path)
The components inside the Event instead of the Trigger, is more of a personal preference, but we already store the target Entity inside the Event, and so having all the data of which to match an Observer, be on the Event seemed fitting.

@SkiFire13 SkiFire13 Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a Mutation shouldn't ever be an structural change, so no archetypes will ever change, so passing them is assumed to be useless information.

Mhm yeah passing archetypes here is not ideal either. Edit: could we maybe move them onto the various lifecycle events as ArchetypeId?

The components inside the Event instead of the Trigger, is more of a personal preference, but we already store the target Entity inside the Event, and so having all the data of which to match an Observer, be on the Event seemed fitting.

Why don't we do this for the other lifecycle events too then?

Storing the components on the Event also forces you to use Vec instead of a borrowed slice since Event must be 'static.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhm yeah passing archetypes here is not ideal either. Edit: could we maybe move them onto the various lifecycle events as ArchetypeId?

I have thought and suggested a thing before, we would need to discuss with Doot as they are working on a Replace Event and are wanting direct access to Archetypes for perf, but perhaps it would still be okay with ArchetypeId's?

Why don't we do this for the other lifecycle events too then?

Storing the components on the Event also forces you to use Vec instead of a borrowed slice since Event must be 'static.

Same as above, I would prefer it, but some Birbs have a differing opinion and so we would need to discuss that.

Comment on lines +420 to +425
pub struct MutateEvent {
/// Target Entity of the event.
pub entity: Entity,
/// Target Components of the event.
pub components: Vec<ComponentId>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having to create a Vec<ComponentId> for every MutateEvent feels pretty wasteful.

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it, and its kinda wasteful regardless, either way most Lifecycle events are creating temporary slices and passing that around which creates copies of the whole slice on the stack, where this just copies the Vec pointer to the heap. It also makes triggering the Event significantly easier, since there is no longer any lifetimes involved, which is also makes it ergonomic/easy to use with Commands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

way most Lifecycle events are creating temporary slices and passing that around which creates copies of the whole slice on the stack, where this just copies the Vec pointer to the heap

I'm not sure what you mean here, the slice is passed as part of the trigger, which is always passed as a reference, just like the event is, and thus the slice is never copied onto the stack. But eitherway this cost is significantly lower than creating a whole new heap allocation. I encourage you to benchmark this, but I suspect no benchmark is gonna have good results with a heap allocation in this critical path.

which is also makes it ergonomic/easy to use with Commands

I'm not sure why someone would want to manually trigger a MutateEvent through commands. This should only be triggered by the ECS.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean here, the slice is passed as part of the trigger, which is always passed as a reference, just like the event is, and thus the slice is never copied onto the stack. But eitherway this cost is significantly lower than creating a whole new heap allocation. I encourage you to benchmark this, but I suspect no benchmark is gonna have good results with a heap allocation in this critical path.

This is only because the 'normal' path for current Lifecycle Events are all done at sync-points via an Entity Command, so they are all guaranteed the apply path which makes references always valid, but if needed in a queue path then it requires you to copy the Id's anyways, and relookup Archetype references.

I'm not sure why someone would want to manually trigger a MutateEvent through commands. This should only be triggered by the ECS.

Allowing manual access is always a necessity, otherwise it takes away the 'engine code is user code' feel. There are no invariants required by Mutate to not be manually triggered, so it would be an artificial limitation of that fact you are passing references instead of Id's.
I've tried working on Lifecycle Events for Disabled Component, and it was alot more difficult then it needed to be since I was forced to use Events via Commands, and trying to use Lifecycle Events with Commands is as stated above, is less then ideal.

) {
if APPLY {
if has_hooks {
// SAFETY: DeferredWorld-Access

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SAFETY comment doesn't really explain what's going on here.

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I am not exactly sure what to put for most of these, except that we have Deferred World Access, which implies we cant change anything structurally, and most of the unsafe is only unsafe because of that.
Like I could explain that whole sentence out, but it shows up in like 8 places in the single function all for the same reasons.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

except that we have Deferred World Access, which implies we cant change anything structurally,

This is completely unrelated to this unsafe usage though?

You're calling trigger_on_mutate, whose safety doc says "Caller must ensure ComponentId in target exist in self". Here you should then explain why it's guaranteed that the ComponentIds in comps.iter().copied() all exist in Self.

It seems to me that this is not guaranteed because this function takes a Vec<ComponentId> as input and is safe, so the caller can pass in any ComponentId, even those that don't exist in this World.

The trigger_raw call down below also has a safety requirement that's completely unrelated to the use of DeferredWorld, and the other unsafe usages are on a &mut World but still mention DeferredWorld-Access!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can go through and update the Safety comments for the trigger_raw and trigger_on_mutate to match their Safety comments on the method.
The other unsafes are all done via DeferredWorld::deref()->&World::* there is no &mut World access in any Mutate function, so that invariant is what is upholding those, again I could explain why only having DeferredWorld access implies that but that is already covered by the DeferredWorld type itself.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I got confused by the self.commands().queue(move |world: &mut World| { (which does give you a &mut World), but I failed to notice that you later convert it to a DeferredWorld. Given this then the branches are again almost the same:

if APPLY {
    // ...
} else {
    self.commands().queue(move |world: &mut World| {
        // SAFETY: We have exclusive access to [`World`] in [`Command`]
        let mut world = unsafe { world.as_unsafe_world_cell().into_deferred() };
        // ... same as before
    });        
}

Btw you can safely get a DeferredWorld from a &mut World using DeferredWorld::from(world), no need to use unsafe there.

Comment on lines +445 to +536
archs
.filter_map(|arch| /* SAFETY: DeferredWorld-Access */
unsafe {
(*archetypes).get(arch)
})
.for_each(|a| {
let has_hooks = a.has_mutate_hook();
let has_observers = a.has_mutate_observer() || has_global_or_entity_observers;
if !has_hooks && !has_observers {
return;
}
a.entities().iter().for_each(|e| {
let entity = e.id();
let table_row = e.table_row();
let comps =
match muts {
Included(m) => {
m.iter()
.filter(|c| {
a.get_storage_type(*c).is_some_and(|s| {
match s {
Table => {
// SAFETY: DeferredWorld-Access
let tables = unsafe { &*tables };
tables.get(a.table_id()).is_some_and(|t| {
t.get_changed_tick(*c, table_row)
.is_some_and(|tick| {
// SAFETY: DeferredWorld-Access
unsafe { *(tick.get()) == last_run }
})
})
}

SparseSet => {
// SAFETY: DeferredWorld-Access
let sparse_sets = unsafe { &*sparse_sets };
sparse_sets.get(*c).is_some_and(|s_s| {
s_s.get_changed_tick(entity).is_some_and(
|tick| {
// SAFETY: DeferredWorld-Access
unsafe { *(tick.get()) == last_run }
},
)
})
}
}
})
})
.collect::<Vec<_>>()
}
Excluded(m) => {
// Unbounded Access, so naively scan all components not excluded.
a.iter_components()
.filter(|c| m.contains(*c))
.filter(|c| {
a.get_storage_type(*c).is_some_and(|s| {
match s {
Table => {
// SAFETY: DeferredWorld-Access
let tables = unsafe { &*tables };
tables.get(a.table_id()).is_some_and(|t| {
t.get_changed_tick(*c, table_row)
.is_some_and(|tick| {
// SAFETY: DeferredWorld-Access
unsafe { *(tick.get()) == last_run }
})
})
}

SparseSet => {
// SAFETY: DeferredWorld-Access
let sparse_sets = unsafe { &*sparse_sets };
sparse_sets.get(*c).is_some_and(|s_s| {
s_s.get_changed_tick(entity).is_some_and(
|tick| {
// SAFETY: DeferredWorld-Access
unsafe { *(tick.get()) == last_run }
},
)
})
}
}
})
})
.collect::<Vec<_>>()
}
};
if !comps.is_empty() {
world.trigger_mutate::<APPLY>(entity, comps, has_hooks, has_observers, loc);
}
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks very hard to read 😅

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to split things up as much as possible and condense the code where I could, but most of what is in this methods is unique to the impl.
Unless you meant the lack of comments I suppose, which is fair.

@SkiFire13 SkiFire13 Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It appears to me that there's quite some duplication in this code though. For example the two parts that do a.get_storage_type(*c).is_some_and(|s| { ... } look completely equal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your right, I overlooked the Storage match and scan, that could be abstracted out.

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually when looking it over, there are three versions with slight differences:

  1. Multiple Components, Multiple Entities (Query)
  2. Single Resource, Single Entity (ResMut)
  3. Single Resource, Multiple Entity (FilteredResourcesMut)

Both 1 and 3 are the most alike, but slightly different in that 1 needs to return and collect the Components to trigger them as a single Event for the Entity, but 3 does not need to do that and can instead trigger inline because it is a Resource and they are not shared between entities.

2 is similar but does more upfront, since it knows its dealing with only a single value/entity.

So while they technically could be merged/abstracted I think it would just lead to either confusing code or worse performance.

@@ -1 +1 @@
#![expect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that mutate hooks are not called when e.g. EntityMut::get_mut is used to mutate components.

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean from an exclusive System / &mut World? I didnt impl for World as a SystemParam, we could do that.
The others were straight forward and had little options for alternatives, but direct world access is probably best done with a direct push style, or a new Mut wrapper that has access to Commands to trigger an Mutate on DerefMut.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean even outside a system.

If I create a new world, register a Mutate event/hook, then mutate a component using EntityMut::get_mut, then I would expect the event/hook to fire.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah so probably a custom Mut wrapper, or adding to the existing one returned by World.

@Diddykonga Diddykonga Aug 17, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got an branch stacked on this one, that has a working doc-test for this:

    /// #[derive(Component, Debug)]
    /// #[component(on_mutate)]
    /// pub struct Comp(pub u32);
    ///
    /// impl Comp {
    ///     fn on_mutate(mut world: DeferredWorld<'_>, hook: HookContext) {
    ///         let c = world.entity(hook.entity).get::<Comp>().unwrap();
    ///         assert!(false, "Hook: {c:?}");
    ///     }
    /// }
    ///
    /// fn observer(on: On<Mutate<Comp>>, query: Query<&Comp>) {
    ///     let c = query.get(on.entity).unwrap();
    ///     assert!(false, "Observer: {c:?}");
    /// }
    ///
    /// let mut world = World::default();
    /// world.add_observer(observer);
    /// {
    ///     let mut e = world.spawn(Comp(0));
    ///     let mut c = e.into_mut_c::<Comp>().unwrap();
    ///     c.0 = 25;
    /// }
    /// world.flush(); // Asserts with "Hook: Comp(25)", or "Observer: Comp(25)" if no hook.

Have to go through and replace all the instances of Mut from direct World-access now.
For the impl, I made another Wrapper, that Wraps Mut and an EntityDeferredWorld. (EntityDeferredWorld = (Entity, DeferredWorld))

@@ -1 +1 @@
#![expect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this PR really needs some benchmarks to understand the impact of this feature.

@Diddykonga Diddykonga Aug 16, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I've never written any and the setup for it looks a bit daunting 😅
I do agree though, I would be interested in seeing some numbers, because when I ran the bevy_city example with and without this PR, I used an entity observer for each car with and without Transform as an match term, and couldn't notice anything given that my FPS was already pretty inconsistent.

@Zeophlite Zeophlite added the S-Merge-Conflicts Merge conflicts :( Add this label on top of other S- labels. label Sep 1, 2026
@Diddykonga Diddykonga closed this Sep 16, 2026
@github-project-automation github-project-automation Bot moved this from Needs SME Triage to Done in ECS Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-ECS Entities, components, systems, and events C-Feature A new feature, making something new possible S-Merge-Conflicts Merge conflicts :( Add this label on top of other S- labels. S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants